refactor(client): consolidate Badge/Modal reuse, add shared hooks, trap modal focus#1848
Conversation
…ap modal focus - KeyboardShortcutsHelp now renders through the shared Modal (was a hand-rolled dialog with no portal, Escape handling, or focus management) - Modal gains a Tab-cycling focus trap; PhotoMetadataModal's private trap implementation is removed in favor of the shared one - BudgetHealthIndicator, UpcomingMilestonesCard, CriticalPathCard, and SubsidyPipelineCard now use the shared Badge component with new Badge.module.css variants instead of hand-rolled status spans - Add useDebounce, useDebouncedCallback, and useClickOutside hooks, and migrate 8 duplicated debounce/click-outside implementations onto them (SearchPicker, DocumentBrowser, DiaryPage, BudgetOverviewPage, DiaryEntryEditPage, DataTableColumnSettings, OverflowMenu) - Remove stale StatusBadge/HouseholdItemStatusBadge directories (components were already consolidated into Badge; only orphaned test files remained) - Wire the existing (already-translated) i18n key for WorkItemDetailPage's subtasks empty state instead of hardcoded English Deferred to follow-up issues (documented in the PR description): the 25+ ad-hoc FormError/EmptyState bypass sites cited in #1816, DataTableHeader's click-outside (needs a ref-forwarding change to a sibling component), and a pre-existing autosave-on-mount bug in DiaryEntryEditPage found by QA while testing this refactor (unrelated to the debounce-hook migration). Fixes #1816 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
…fect re-fire loops
useDebouncedCallback returned a brand-new `{ trigger, cancel }` object literal
on every render, even though `trigger`/`cancel` were each individually stable
via useCallback. Any consumer effect that depended on the whole returned
object (not just its members) saw that dependency "change" on every render,
causing the effect to re-fire spuriously.
This was the deterministic root cause of the E2E smoke failure on
tests/budget/budget-overview-no-hero-card.spec.ts:328 (PR #1848): in
BudgetOverviewPage, the debounce-refetch effect depends on
`scheduleFetchBreakdown`, so every render — including the render caused by
the effect's own setIsBreakdownRefetching(true) — re-triggered the effect,
producing an unbounded refetch ping-pong. isBreakdownRefetching never
settled to false, so the `.breakdownRefetching` (pointer-events: none)
overlay stayed applied over the breakdown table, and Playwright's click on
"Expand work item budget by area" timed out with the ancestor PageLayout
content wrapper reported as intercepting pointer events. The same pattern
also silently cancelled pending debounced autosaves in DiaryEntryEditPage on
any unrelated re-render.
Fix: wrap the hook's return value in `useMemo(() => ({ trigger, cancel }),
[trigger, cancel])` so identity is stable whenever trigger/cancel haven't
changed. No page-level code changes were needed — the effect dependency
arrays were already correct per exhaustive-deps; they were only broken by
the hook's instability.
Adds a regression test for object-identity stability in
useDebouncedCallback.test.ts (the existing test only checked
trigger/cancel individually, not the containing object), plus regression
tests in BudgetOverviewPage.test.tsx (refetch settles and stops
re-triggering) and DiaryEntryEditPage.test.tsx (a pending debounced
autosave survives an unrelated re-render).
Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
|
[security-engineer] Security review of PR #1848 (fix/1816-component-reuse) Verdict: APPROVED Reviewed Modal focus trap (
No SQL/command/XSS injection vectors, no auth/authz surface touched (client-only), no sensitive data handled differently, no new dependencies to check for CVEs. Test coverage for the new hooks and the focus-trap boundary conditions is thorough and confirms the implementation matches the described behavior. |
|
[product-architect] Architecture / contract review of the component-reuse refactor. Verdict: APPROVED — no contract impact, faithful consolidations, and the regression the first commit introduced was correctly root-caused and fixed with a mutation-tested guard. Findings below are medium/low for follow-up, none blocking. Contract impactNone. The diff touches only Hook API design
FindingsMEDIUM — Document the referential-stability guarantee on MEDIUM — This incident is a generalizable convention worth codifying (I own this; tracking as a follow-up, not blocking this PR). LOW / informational — Modal focus-trap a11y.
LOW / informational — Nice work on the RCA + regression/mutation tests for the debounce fix — that is the right depth of response to an E2E-caught bug. |
steilerDev
left a comment
There was a problem hiding this comment.
[ux-designer] Design review of PR #1848 (fix/1816-component-reuse), scoped to git diff origin/beta..HEAD under client/.
1. Modal focus trap — PASS
getFocusableElements() (client/src/components/Modal/Modal.tsx:15-21) is queried fresh on every Tab keypress against contentRef.current, which wraps header + body + footer, so all of a modal's interactive elements (including footer action buttons) are included — verified this correctly subsumes what PhotoMetadataModal's deleted private trap used to query for ('[role="dialog"] button, ...').
- Zero focusable elements: guarded by
if (focusable.length === 0) return;— no-op, no throw. - Single focusable element:
first === last, so both Tab and Shift+Tab re-focus the same element — correct self-loop, andModal.test.tsx's new "single focusable" test confirms it (a modal's own close button always guarantees at least one focusable element in practice, so true zero is more theoretical than reachable here — fine). - Tab/Shift+Tab wrap at both boundaries, non-Tab keys are ignored (
e.key !== 'Tab'early return, confirmed doesn't fireonCloseor move focus), and mid-list Tab presses are left to native browser behavior — all covered by the 6 newModal.test.tsxcases. KeyboardShortcutsHelpnow correctly inherits portal/Escape/focus-trap/dialog semantics it previously hand-rolled without. Verified via the rewritten test file:role="dialog"+aria-modal="true"present, Escape closes, close-button aria-label now resolves through the sharedcommon:aria.closeDialogkey.
Minor/informational, already flagged by security-engineer and not reintroduced by this PR: each Modal instance's Tab listener is document-scoped, so two simultaneously stacked modals would both react to Tab. No current call site stacks modals — not a regression, just a note for future modal-inside-modal features.
2. Badge consolidation — verify token fidelity — 2 findings, both non-blocking
Dark mode: confirmed every new variant class (budgetHealth*, schedule*, subsidy*) routes through Layer 2 semantic tokens with existing [data-theme='dark'] overrides (--color-success-badge-bg/-text, --color-danger-bg(-strong), --color-danger-text-on-light, --color-status-not-started-bg/-text, --color-status-in-progress-bg/-text, --color-warning-bg) — no new dark-mode gaps.
Naming fits the existing variant scheme (compare .not_started/.in_progress WorkItemStatus group, .planned/.purchased HouseholdItemStatus group already in the file) — grouping comments and prefix convention (budgetHealth*/schedule*/subsidy*) are consistent and readable.
Finding (Medium, non-blocking): the "ported verbatim / zero visual delta" claim doesn't hold for all three source components. The shared .badge base (padding: var(--spacing-1) var(--spacing-2-5); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium)) was unified from StatusBadge/HouseholdItemStatusBadge/DiaryOutcomeBadge/DiarySeverityBadge — but the three newly-migrated components each had their own, different base sizing that this consolidation silently overrides:
| Component | Old padding (v/h) | New padding (v/h) | Old font-size | New font-size | Old weight | New weight |
|---|---|---|---|---|---|---|
SubsidyPipelineCard |
2px / 8px | 4px / 10px | 12px (xs) | 12px (xs) | 500 (medium) | 500 (medium) |
CriticalPathCard/UpcomingMilestonesCard |
4px / 8px | 4px / 10px | 12px (xs) | 12px (xs) | 500 (medium) | 500 (medium) |
BudgetHealthIndicator |
4px / 12px | 4px / 10px | 14px (sm) | 12px (xs) | 600 (semibold) | 500 (medium) |
The first two are a harmless ±2px padding nudge (both already used spacing tokens, just different ones — a reasonable side-effect of standardizing on one base). BudgetHealthIndicator is a real, visible downgrade: its badge shrinks from 14px semibold to 12px medium and loses 2px of horizontal breathing room. Colors/contrast are unchanged (no WCAG issue), but this is an important status indicator (On Budget / At Risk / Over Budget) and the size/weight drop wasn't called out in the PR description as an intentional design change. Recommend either: (a) accept the downsize explicitly as the new standard badge size across the app, or (b) give BudgetHealthIndicator a size modifier (e.g. a .badgeLg or similar composable modifier class alongside .badge) to preserve its original prominence. Either is fine — just needs to be a decision, not a side effect of the refactor. Non-blocking; fine to fix in refinement.
KeyboardShortcutsHelp td padding (0.875rem → var(--spacing-4), 14px→16px): acceptable. tokens.css has no 14px spacing token (only --spacing-3=12px / --spacing-4=16px), so snapping up to the nearest token is the correct call under a token-only system — 2px is imperceptible on table row padding. th/kbd/mobile-override paddings are all now token-backed with no other deltas. No dead CSS left behind (.modal/.modalBackdrop/.modalContent/.modalHeader/.modalTitle/.closeButton chrome correctly fully removed now that Modal owns it; .keyColumn/.descriptionColumn/.keyCell/.descriptionCell all still referenced in the component).
3. CriticalPathCard div→span — PASS
Old markup was <div className={\${styles.badge} ${healthClass}`}>withstyles.badge { display: inline-flex; ... }— a
with its default block display already overridden toinline-flex. New renders awith the samedisplay: inline-flexin the shared base class. Both resolve to the same computeddisplay: inline-flex`, so the claim of no layout shift holds — this is a tag-semantics cleanup (span is the more correct element for inline status text) with no visual side effect.
4. BudgetHealthIndicator role="status" wrapper — PASS
<span role="status"><Badge .../></span> (client/src/components/BudgetHealthIndicator/BudgetHealthIndicator.tsx:45-47) keeps the live region on an ancestor of the badge text, so aria-live="polite" (implicit from role="status") still fires when the nested Badge's text content changes on a status transition. role="status" is applied exactly once (not duplicated onto the inner Badge span) — correct, no redundant aria-live per the recurring-bug note in my own review history. BudgetHealthIndicator.test.tsx was updated to assert the class on status.querySelector('span') rather than on the status element itself, matching the new two-span structure.
Informational (not blocking)
common.json'skeyboardShortcuts.closeAriaLabel(en: "Close", de: "Schließen") is now orphaned —KeyboardShortcutsHelpno longer needs its own aria-label sinceModalsuppliescommon:aria.closeDialog. Not part of this PR's stated cleanup scope; flagging for a follow-up i18n-orphan sweep rather than blocking here.
Verdict: APPROVED (non-blocking comment)
Modal focus trap (item 1), CriticalPathCard tag change (item 3), and the role="status" wrapper (item 4) all check out exactly as described. Item 2's dark-mode/naming/token-usage checks all pass; the one real finding — BudgetHealthIndicator's font-size/weight/padding shrink — is a Medium, non-blocking visual-consistency item per the design system's severity policy (no accessibility or dark-mode regression), safe to address in refinement or as a quick follow-up commit before merge, reviewer's choice.
|
🎉 This PR is included in version 2.13.0-beta.17 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Addresses the component-reuse violations flagged in #1816 (Medium aggregate, two High items — one already resolved by #1812's dead-code deletion):
Modal, inheriting all of that for free.Modalgains Tab-cycling focus trap (the checklist's own "Focus management: Modals must trap focus" requirement).PhotoMetadataModal's private, near-identical trap implementation is removed in favor of the shared one — all 18Modalconsumers benefit.BudgetHealthIndicator,UpcomingMilestonesCard,CriticalPathCard(an uncited sibling ofUpcomingMilestonesCardfound in the same file/CSS module during the migration), andSubsidyPipelineCardnow use the sharedBadgecomponent +BadgeVariantMapinstead of hand-rolled status spans with local class maps.useDebounce,useDebouncedCallback,useClickOutside— extracted from 8 previously-duplicated implementations (SearchPicker,DocumentBrowser,DiaryPage,BudgetOverviewPage,DiaryEntryEditPage,DataTableColumnSettings,OverflowMenu).StatusBadge/andHouseholdItemStatusBadge/directories (components were already consolidated intoBadge; only orphaned test files remained).WorkItemDetailPage's subtasks empty state instead of hardcoded English.Fixes #1816
Deferred to follow-up issues (scope discipline, documented in the dev-team-lead spec)
FormError/EmptyStatebypass sites cited in Component-reuse violations: SearchPicker/Modal forks, Badge reimplemented 3x, FormError/EmptyState bypassed, missing shared hooks #1816 — confirmed mechanical, but sized for their own PR given this PR already touches the sharedModal(18 consumers) and adds 3 new hooks across 8 files.DataTableHeader.tsx's click-outside duplication — its exclusion check matches a popover rendered by a sibling component that doesn't currently forward a ref; converting it cleanly needs aforwardRefchange to that sibling, which is scope beyond a hook swap.DocumentSkeleton.tsxand the plain-text loading divs — the issue itself marks these as judgment calls requiring visual redesign.DiaryEntryEditPagefires one unwanted immediate autosave on mounting a draft entry (skipAutoSaveOnMountRefis consumed on a no-op render beforeentryfinishes loading, so the guard is spent by the time the form fields actually populate). Confirmed via diff againstorigin/betathat this code path is byte-identical and predates this PR — unrelated to the debounce-hook migration. Worked around in the new regression test (flush +mockClear()) rather than masked; recommend a dedicated follow-up issue.Test plan
npx tsc --noEmitclean (verified independently in review)npm run stylelintclean (verified independently in review)npx eslintclean on all changed files — no new warnings (verified independently in review; 2 pre-existing unrelated warnings elsewhere in the codebase, untouched by this PR)npx prettier --checkclean on all changed files (verified independently in review)Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) noreply@anthropic.com
Co-Authored-By: Claude frontend-developer (Haiku 4.5) noreply@anthropic.com
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) noreply@anthropic.com